Accès projet git : https://github.com/Adrian1903/Categorisez-automatiquement-des-questions
Plus d'informations : https://openclassrooms.com/fr/paths/148-ingenieur-machine-learning
Stack Overflow est un site célèbre de questions-réponses liées au développement informatique. Pour poser une question sur ce site, il faut entrer plusieurs tags de manière à retrouver facilement la question par la suite. Pour les utilisateurs expérimentés, cela ne pose pas de problème, mais pour les nouveaux utilisateurs, il serait judicieux de suggérer quelques tags relatifs à la question posée.
Amateur de Stack Overflow, qui vous a souvent sauvé la mise, vous décidez d'aider la communauté en retour. Pour cela, vous développez un système de suggestion de tag pour le site. Celui-ci prendra la forme d’un algorithme de machine learning qui assigne automatiquement plusieurs tags pertinents à une question.
Les données Stack Overflow propose un outil d’export de données - "stackexchange explorer", qui recense un grand nombre de données authentiques de la plateforme d’entraide.
Contraintes :
import pandas as pd
pd.options.display.max_columns = None
from collections import Counter
from IPython.display import Image
import seaborn as sns
import matplotlib.pyplot as plt
plt.style.use("default")
from wordcloud import WordCloud
import PIL.Image
import en_core_web_sm
import en_core_web_md
import spacy
from spacy.lang.en.stop_words import STOP_WORDS
from functions import *
start_notebook = time.time()
Les données ont été extraite du site stackexchangeexplorer. J'ai traité des post récents.
Requête SQL :
DECLARE @max_date as DATETIME = DATEADD(MONTH, -1, GETDATE())
SELECT Id, Title, Body, Tags
FROM posts
WHERE CreationDate < @max_date AND PostTypeId = 1 AND Score > 19
ORDER BY CreationDate DESC
Les données ont été stockées dans le fichier 'Questions50K_QueryResults.csv'.
questions_raw = pd.read_csv('src/Questions50K_QueryResults.csv')
questions_raw.head()
questions_raw.shape
questions_raw.columns
questions_raw.isna().sum()
questions = questions_raw.copy()
Après l'exploration des tags, je vais en premier lieu définir ceux à conserver selon la loi de pareto, puis supprimer les autres du jeu de données. Les questions se retrouvant sans tags seront supprimées.
Je cherche à connaître quels sont les tags les plus utilisés
questions.Tags = questions.Tags.apply(lambda x: x.replace('<', '').replace('>', ',')).str.slice(0, -1).str.replace('-', '_')
questions.Tags
tags = questions.Tags.str.split(',').explode().reset_index()
tags = tags.groupby('Tags').count().sort_values(by='index', ascending=False).reset_index().rename(columns={'index': 'recurrence'})
print(f'Nous avons {len(tags)} tags utilisés sur la période')
tags
text = (tags.Tags + ' ') * tags['recurrence']
text = ''.join(text)
# Création de l'objet
mask = np.array(PIL.Image.open('src/bulle.jpg'))
wordcloud = WordCloud(width=1200, height=780, background_color="rgba(255, 255, 255, 0)", mode="RGBA", collocations=False, mask=mask).generate(text)
# Génération de l'image
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.margins(x=0, y=0)
plt.savefig('img/img_wordcloud_tags.png',
dpi=500, quality=95,
transparent=True,
bbox_inches="tight")
plt.show()
# Nombre de tags utilisés par question
tags_count = questions.Tags.str.split(',').apply(lambda x: len(x)).reset_index()
tags_count = tags_count.groupby('Tags').count().reset_index()
plt.clf()
sns.barplot(y='Tags',
x='index',
data=tags_count,
orient='h')
plt.title('Nombre de tags utilisés par question')
plt.xlabel('Volume de post')
plt.ylabel('Nombre de tags')
plt.savefig('img/img_tags_count.png',
transparent=True,
bbox_inches="tight")
# Calcul de l'importance des tags
tags_imp = (tags['recurrence'].cumsum() / tags['recurrence'].sum()) * 100
tags_imp = tags_imp.reset_index().rename(columns={'recurrence': 'cumsum_percent'})
vol_treshold = 80
qty_tags = len(tags_imp[tags_imp.cumsum_percent < vol_treshold])
vol_qty_tags = round(qty_tags / len(tags_imp) * 100, 0)
print(f'{vol_treshold} % du volume des tags est expliqué par {qty_tags} tags soit une proportion de {vol_qty_tags} % de la population des tags')
sns.lineplot(x='index', y='cumsum_percent', data=tags_imp)
plt.axhline(y=vol_treshold, color='red', linestyle='--')
plt.title("Somme cumulée d'utilisation des tags")
plt.ylabel('Somme cumulée')
plt.xlabel('Index des tags')
x = range(len(tags))
y1 = vol_treshold
plt.fill_between(x, y1, 100, color="red", alpha=0.3)
plt.annotate("Seuil : " + str(vol_treshold) + " %", (len(tags) / 2, vol_treshold - 5), color="red")
plt.savefig('img/img_pareto_tag.png',
transparent=True,
bbox_inches="tight")
plt.show()
bag_tags = tags.head(qty_tags)
bag_tags
Les tags les moins souvent utilisés l'ont été au moins 20 fois sur des sujets reconnus et pertinents.
# Constitution d'une liste
bag_tags_lst = bag_tags.Tags.to_list()
Je dois faire attention que certains tags ne soient pas également des stopwords, car il seront protégés. je dois les retirer
[display(bag_tags[bag_tags.Tags == i]) for i in bag_tags_lst if i in STOP_WORDS]
bag_tags_lst.remove('go')
bag_tags_lst.remove('this')
display('go' in bag_tags_lst)
display('this' in bag_tags_lst)
# Recherche de la ponctuation présente dans les tags
punct = re.sub('[\w\s]', '', ' '.join(bag_tags_lst))
punct = repr(set([p for p in punct]))
print(f'Les ponctutations ' + punct + ' seront protégées en cas de suppression de ponctuation')
Spoil : Enlever les ponctuations diminue la précision du modèle final. A vouloir trop nettoyer, je perds en précision. je laisse les ponctuations pour ce nettoyage.
questions.Tags.head(20)
i = 0
for sub in questions.Tags.str.split(','):
lst = []
for s_sub in sub:
if s_sub in bag_tags_lst:
lst.append(s_sub)
questions.Tags[i] = ','.join(lst)
i += 1
questions.Tags.head(20)
tags = questions.Tags.str.split(',').explode().reset_index()
tags = tags.groupby('Tags').count().sort_values(by='index', ascending=False).reset_index().rename(columns={'index': 'recurrence'})
print(f'Nous avons {len(tags)} tags utilisés sur la période')
tags
# Suppresion des questions sans tags
questions[questions.Tags == '']
On se retrouve avec des questions sans tags. Je les retire.
questions = questions[questions.Tags != '']
# Backup de la liste de tags pour la production
pd.DataFrame(bag_tags_lst, columns=['list']).to_csv('api/src/bag_tags.csv', index=False)
En procédant à un test maison, je me rends facilement compte que je peux mettre des informations importante soit dans le titre, soit dans le body. Je ne pense pas forcément à les mettre dans les 2 blocs. De ce fait, je dois analyser le champs Body et le champs Title.
Image('src/post.png')
questions['Title_Body'] = questions.Title + ' ' + questions.Body
questions
Pour y arriver, je vais m'aider du pipeline sPacy. Pour ce projet, je n'ai pas besoin de la reconnaissance de nom (NER), ce composant sera désactivé pour des économies de calcul.
Image('src/question.png')
Lorsque j'analyse une question. Je constate qu'il peut y avoir des blocs de codes, et des blocs d'image. Ces blocs génèrent du bruit et n'apporte pas de ou très peu de valeurs ajoutées au sens de la question. Je décide de les retirer. Les blocs de code non préformaté ont très peu d'incidence et contiennent souvent des tags. Je les conserve.
Je retire également les tags HTML, les retours ligne \n, les éventuels caractères accentués et je passe tous les caractères en minuscule s'il ne le sont pas déjà.
Je constate aussi qu'il peut y avoir des formes contractées des verbes. Ces verbes peuvent être important pour le sens de la phrase. La ponctutation est aussi génante, ca peut être constitué comme un token. Une ponctuation peut également être liées à un mot. Je vais uniformiser tout cela.
nlp = spacy.load('en_core_web_sm', disable=['ner'])
nlp.add_pipe(CleanBeforeTaggerComponent(nlp), first=True)
nlp.add_pipe(CleanContractionsComponent(nlp),
after='CleanBeforeTagger')
print('Pipeline:', nlp.pipe_names)
questions.Title_Body[3]
doc = nlp(questions.Title_Body[3])
for token in doc:
print(token.text, token.pos_, token.dep_, token.head)
Les mots qui sont présent à la racine (ROOT) d'une phrase sont très importants. Ils donnent le sens à la question. De ce fait je dois les conserver. De même pour les noms (NOUN). Je conserve également les mots qui sont présents dans la liste de tags.
Je conserve les versions lemmatisées des mots.
Cette structure me permet de garder un sens à la phrase et les principaux mots-clés.
full_txt = [token.text for token in doc]
txt = [token.lemma_ for token in doc
if ((token.dep_ == 'ROOT' or
token.pos_ == 'NOUN' or
token.pos_ == 'ADJ' or
token.pos_ == 'ADV') and
token.text not in STOP_WORDS) or
token.text in bag_tags_lst]
full_txt = ' '.join(full_txt)
txt = ' '.join(txt)
print(f'Texte avant nettoyage : \n{full_txt}\n\nTexte après nettoyage : \n{txt}')
clean = CleanAfterParserComponent(nlp)
clean.set_protect(bag_tags_lst)
nlp.add_pipe(clean, after='parser')
print('Pipeline:', nlp.pipe_names)
On ne fait pas de stemmization (racine des mots) dans ce projet. Dans le cas suivant, access peut être est un tag (microsoft access). Si on prend la racine de la plupart des mots suivants, on se retrouvera avec des faux tags dans le texte.
'access', 'access_token', 'accessi', 'accessibility', 'accessibilitybundle', 'accessibilityinfo', 'accessible', 'accessor', 'accessorily', 'accessory', 'accessright',
%time questions['Cleaned_Title_Body'] = questions.Title_Body.apply(lambda x: nlp(x)).astype(str)
display(questions_raw['Title'][0])
display(questions_raw['Body'][0])
questions.Cleaned_Title_Body[0]
text = ' '.join(questions.Cleaned_Title_Body)
# Création de l'objet
mask = np.array(PIL.Image.open('src/cloud.png'))
wordcloud = WordCloud(width=1200,
height=780,
background_color="rgba(255, 255, 255, 0)",
mode="RGBA",
collocations=False,
mask=mask).generate(text)
# Génération de l'image
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.margins(x=0, y=0)
plt.savefig('img/img_wordcloud_tokens.png',
dpi=500, quality=95,
transparent=True,
bbox_inches="tight")
Comme pour la sélection de tags, je vais appliquer un loi de pareto aux mots. Car beaucoup ne paraissent qu'une fois dans l'ensemble du jeu de donnée et ne seront pas utile au modèle. Ils ne feront qu'augmenter le temps de calcul. De même, certains parraisent très souvent et n'apportera aucune valeur au modèle, car très généraliste. Ces derniers pourrait être intégrer à des stopwords "métiers"
nlp_word = pd.DataFrame(text.split(' '), columns=['word']).reset_index()
nlp_word = nlp_word.groupby('word').count().sort_values(by='index', ascending=False).reset_index().rename(columns={'index': 'recurrence'})
nlp_word
# Calcul de l'importance des mots
word_imp = (nlp_word['recurrence'].cumsum() / nlp_word['recurrence'].sum()) * 100
word_imp = word_imp.reset_index().rename(columns={'recurrence': 'cumsum_percent'})
vol_treshold_up = 100
vol_treshold_down = 0
qty_words_up = len(word_imp[word_imp.cumsum_percent < vol_treshold_up])
vol_qty_words = round(qty_words_up / len(word_imp) * 100, 0)
print(f'{vol_treshold_up} % du volume des mots est expliqué par {qty_words_up} mots soit une proportion de {vol_qty_words} % de la population des mots')
qty_words_down = len(word_imp[word_imp.cumsum_percent < vol_treshold_down])
vol_qty_words_down = round(qty_words_down / len(word_imp) * 100, 0)
print(f'{vol_treshold_down} % du volume des mots est expliqué par {qty_words_down} mots soit une proportion de {vol_qty_words_down} % de la population des mots')
sns.lineplot(x='index', y='cumsum_percent', data=word_imp)
plt.axhline(y=vol_treshold_up, color='red', linestyle='--')
plt.axhline(y=vol_treshold_down, color='red', linestyle='--')
plt.title("Somme cumulée d'utilisation des mots")
plt.ylabel('Somme cumulée')
plt.xlabel('Index des mots')
x = range(len(nlp_word))
y1 = vol_treshold_down
y2 = vol_treshold_up
plt.fill_between(x, y1, color="red", alpha=0.3)
plt.fill_between(x, y2, 100, color='red', alpha=0.3)
plt.annotate("Seuil : " + str(vol_treshold_up) + " %", (len(nlp_word) / 2, vol_treshold_up - 5), color="red")
plt.annotate("Seuil : " + str(vol_treshold_down) + " %", (len(nlp_word) / 2, vol_treshold_down + 2), color="red")
plt.savefig('img/img_pareto_words.png',
dpi=500, quality=95,
transparent=True,
bbox_inches="tight")
plt.show()
bag_words = nlp_word.copy()
min_rec_word = bag_words.iloc[qty_words_up].recurrence
print(f'je conserve les mots qui sont utilisés au moins {min_rec_word} fois')
bag_words = bag_words[bag_words.recurrence >= min_rec_word]
bag_words.tail(10)
max_rec_word = bag_words.iloc[qty_words_down].recurrence
print(f'je supprime les mots qui sont utilisés plus de {max_rec_word} fois')
bag_words = bag_words[bag_words.recurrence <= max_rec_word]
bag_words.head(10)
# Je rejette les mots non conservé
if len(bag_words) != len(nlp_word):
print('Rejet des mots en cours')
for r in range(4):
# J'ai besoin d'éxécuter plusieurs fois pour que l'ensemble des mots à rejeter le soit
i = 0
print(f'Boucle {r}')
for sub in questions.Cleaned_Title_Body.str.split():
lst = []
for s_sub in sub:
if s_sub in bag_words.to_list():
lst.append(s_sub)
questions.Cleaned_Title_Body[i] = ' '.join(lst)
i += 1
# Ne pas supprimer | Il y a une différence entre les 2 formulations
questions['Cleaned_Title_Body'] = questions.Cleaned_Title_Body
else: print('Aucun mot à rejeter')
# Vérification
cleaned_nlp_word = ' '.join(questions.Cleaned_Title_Body)
cleaned_nlp_word = pd.DataFrame(cleaned_nlp_word.split(' '), columns=['word']).reset_index()
cleaned_nlp_word = cleaned_nlp_word.groupby('word').count().sort_values(by='index', ascending=False).reset_index().rename(columns={'index': 'recurrence'})
cleaned_nlp_word
# Phrases ne contenant aucun mot suite à nettoyage
questions = questions[questions['Cleaned_Title_Body'] != '']
questions.drop(columns=['Title', 'Body'], inplace=True)
questions.isna().sum()
questions.to_csv('src/cleaned_questions.csv', index=False)
end_notebook = time.time()
exec_time(start_notebook, end_notebook)
questions[questions.Id == 31246192]
Le tags ci-dessus est nan. Lors de l'importation du fichier dans le prochain notebook, celui-ci sera reconnu comme NaN. Il sera à corriger